/* * Copyright 2014 gitblit.com. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gitblit.plugin.slack; import static org.pegdown.Extensions.ALL; import static org.pegdown.Extensions.SMARTYPANTS; import java.io.IOException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevSort; import org.eclipse.jgit.revwalk.RevWalk; import org.pegdown.ParsingTimeoutException; import org.pegdown.PegDownProcessor; import org.pegdown.ast.RootNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ro.fortsoft.pf4j.Extension; import com.gitblit.Constants; import com.gitblit.IStoredSettings; import com.gitblit.Keys; import com.gitblit.extensions.TicketHook; import com.gitblit.manager.IGitblit; import com.gitblit.manager.IRepositoryManager; import com.gitblit.manager.IRuntimeManager; import com.gitblit.manager.IUserManager; import com.gitblit.models.RepositoryModel; import com.gitblit.models.TicketModel; import com.gitblit.models.TicketModel.Change; import com.gitblit.models.TicketModel.Patchset; import com.gitblit.models.UserModel; import com.gitblit.plugin.slack.entity.Attachment; import com.gitblit.plugin.slack.entity.Field; import com.gitblit.plugin.slack.entity.Payload; import com.gitblit.servlet.GitblitContext; import com.gitblit.utils.ActivityUtils; import com.gitblit.utils.StringUtils; /** * This hook will post a message to a channel when a ticket is created or updated. * * @author James Moger * */ @Extension public class SlackTicketHook extends TicketHook { final String name = getClass().getSimpleName(); final Logger log = LoggerFactory.getLogger(getClass()); final Slacker slacker; final IStoredSettings settings; public SlackTicketHook() { super(); IRuntimeManager runtimeManager = GitblitContext.getManager(IRuntimeManager.class); Slacker.init(runtimeManager); slacker = Slacker.instance(); settings = runtimeManager.getSettings(); } @Override public void onNewTicket(TicketModel ticket) { if (!shallPost(ticket)) { return; } Set<TicketModel.Field> fieldExclusions = new HashSet<TicketModel.Field>(); fieldExclusions.addAll(Arrays.asList(TicketModel.Field.watchers, TicketModel.Field.voters, TicketModel.Field.status, TicketModel.Field.mentions)); Change change = ticket.changes.get(0); IUserManager userManager = GitblitContext.getManager(IUserManager.class); boolean postAsUser = settings.getBoolean(Plugin.SETTING_POST_AS_USER, true); UserModel user = userManager.getUserModel(change.author); String author; if (postAsUser) { // posting as user, do not BOLD username author = user.getDisplayName(); } else { // posting as Gitblit, BOLD username to draw attention author = "*" + user.getDisplayName() + "*"; } String msg = String.format("%s has created *%s* <%s|ticket-%s>", author, StringUtils.stripDotGit(ticket.repository), getUrl(ticket), ticket.number); Payload payload = Payload .instance(msg) .attachments(fields(ticket, change, fieldExclusions)); attribute(payload, user); slacker.sendAsync(payload); } @Override public void onUpdateTicket(TicketModel ticket, Change change) { if (!shallPost(ticket)) { return; } Set<TicketModel.Field> fieldExclusions = new HashSet<TicketModel.Field>(); fieldExclusions.addAll(Arrays.asList(TicketModel.Field.watchers, TicketModel.Field.voters, TicketModel.Field.mentions, TicketModel.Field.title, TicketModel.Field.body, TicketModel.Field.mergeSha)); IUserManager userManager = GitblitContext.getManager(IUserManager.class); boolean postAsUser = settings.getBoolean(Plugin.SETTING_POST_AS_USER, true); UserModel user = userManager.getUserModel(change.author); String author; if (postAsUser) { // posting as user, do not BOLD username author = user.getDisplayName(); } else { // posting as Gitblit, BOLD username to draw attention author = "*" + user.getDisplayName() + "*"; } String url = String.format("<%s|ticket-%s>", getUrl(ticket), ticket.number); String repo = "*" + StringUtils.stripDotGit(ticket.repository) + "*"; String msg = null; if (change.hasReview()) { /* * Patchset review */ msg = String.format("%s has reviewed %s %s patchset %s-%s", author, repo, url, change.review.patchset, change.review.rev); } else if (change.hasPatchset()) { /* * New Patchset */ String tip = change.patchset.tip; String base; String leadIn; if (change.patchset.rev == 1) { if (change.patchset.number == 1) { /* * Initial proposal */ leadIn = String.format("%s has pushed a proposal for %s %s", author, repo, url); } else { /* * Rewritten patchset */ leadIn = String.format("%s has rewritten the patchset for %s %s (%s)", author, repo, url, change.patchset.type); } base = change.patchset.base; } else { /* * Fast-forward patchset update */ leadIn = String.format("%s has added %s %s to %s %s", author, change.patchset.added, change.patchset.added == 1 ? "commit" : "commits", repo, url); Patchset prev = ticket.getPatchset(change.patchset.number, change.patchset.rev - 1); base = prev.tip; } StringBuilder sb = new StringBuilder(); sb.append(leadIn); // abbreviated commit list List<RevCommit> commits = getCommits(ticket.repository, base, tip); sb.append("\n\n"); int shortIdLen = settings.getInteger(Keys.web.shortCommitIdLength, 6); int maxCommits = 5; for (int i = 0; i < Math.min(maxCommits, commits.size()); i++) { RevCommit commit = commits.get(i); String commitUrl = getUrl(ticket.repository, null, commit.getName()); String shortId = commit.getName().substring(0, shortIdLen); String shortMessage = StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG); String row = String.format("<%s|`%s`> %s\n", commitUrl, shortId, shortMessage); sb.append(row); } // compare link if (commits.size() > 1) { String compareUrl = getUrl(ticket.repository, base, tip); String compareText; if (commits.size() > maxCommits) { int diff = commits.size() - maxCommits; if (diff == 1) { compareText = "1 more commit"; } else { compareText = String.format("%d more commits", diff); } } else { compareText = String.format("view comparison of these %s commits", commits.size()); } sb.append("\n"); sb.append(String.format("<%s|%s>", compareUrl, compareText)); } msg = sb.toString(); } else if (change.isMerge()) { /* * Merged */ msg = String.format("%s has merged %s %s to *%s*", author, repo, url, ticket.mergeTo); } else if (change.isStatusChange()) { /* * Status Change */ msg = String.format("%s has changed the status of %s %s", author, repo, url); } else if (change.hasComment() && settings.getBoolean(Plugin.SETTING_POST_TICKET_COMMENTS, true)) { /* * Comment */ msg = String.format("%s has commented on %s %s", author, repo, url); } if (msg == null) { // not a change we are reporting return; } Payload payload = Payload .instance(msg) .attachments(fields(ticket, change, fieldExclusions)); attribute(payload, user); IRepositoryManager repositoryManager = GitblitContext.getManager(IRepositoryManager.class); RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); slacker.setChannel(repository, payload); slacker.sendAsync(payload); } /** * Optionally stamp the payload with an emoji, icon url, or user attributions. * * @param payload * @param user */ protected void attribute(Payload payload, UserModel user) { IRuntimeManager runtimeManager = GitblitContext.getManager(IRuntimeManager.class); String icon = runtimeManager.getSettings().getString(Plugin.SETTING_TICKET_EMOJI, null); String defaultIcon = runtimeManager.getSettings().getString(Plugin.SETTING_DEFAULT_EMOJI, null); if (StringUtils.isEmpty(icon)) { icon = defaultIcon; } // set the username and gravatar boolean postAsUser = runtimeManager.getSettings().getBoolean(Plugin.SETTING_POST_AS_USER, true); if (postAsUser) { payload.username(user.getDisplayName()); if (!StringUtils.isEmpty(user.emailAddress)) { icon = ActivityUtils.getGravatarThumbnailUrl(user.emailAddress, 36); } } payload.icon(icon); } protected Attachment fields(TicketModel ticket, Change change, Set<TicketModel.Field> fieldExclusions) { Map<TicketModel.Field, String> filtered = new HashMap<TicketModel.Field, String>(); if (change.hasFieldChanges()) { for (Map.Entry<TicketModel.Field, String> fc : change.fields.entrySet()) { if (!fieldExclusions.contains(fc.getKey())) { // field is included filtered.put(fc.getKey(), fc.getValue()); } } } // ensure we have some basic context fields if (!filtered.containsKey(TicketModel.Field.title)) { filtered.put(TicketModel.Field.title, ticket.title); } String text = null; String color = null; if (change.isStatusChange()) { // status change switch (ticket.status) { case Abandoned: case Declined: case Invalid: case Wontfix: case Duplicate: color = "danger"; break; case On_Hold: color = "warning"; break; case Closed: case Fixed: case Merged: case Resolved: color = "good"; break; case New: default: color = null; } } else if (change.hasComment() && settings.getBoolean(Plugin.SETTING_POST_TICKET_COMMENTS, true)) { // transform Markdown comment text = renderMarkdown(change.comment.text, ticket.repository); } // sort by field ordinal List<TicketModel.Field> fields = new ArrayList<TicketModel.Field>(filtered.keySet()); Collections.sort(fields); Attachment attachment = Attachment.instance(ticket.title) .color(color) .text(text); if (fields.size() > 0) { for (TicketModel.Field field : fields) { boolean isShort = TicketModel.Field.title != field && TicketModel.Field.body != field; boolean isMrkdwn = false; String value; if (change.getField(field) == null) { continue; } else { value = change.getField(field); value = filtered.get(field); if (TicketModel.Field.body == field) { // transform the body to Slack markup value = renderMarkdown(value, ticket.repository); isMrkdwn = true; } else if (TicketModel.Field.responsible == field) { // lookup display name of the user value = getDisplayName(value); } if (!StringUtils.isEmpty(value)) { attachment.addField(Field.instance(field.toString(), value).isShort(isShort).isMrkdwn(isMrkdwn)); } } } } return attachment; } protected String renderMarkdown(String markdown, String repository) { if (StringUtils.isEmpty(markdown)) { return markdown; } // extract blockquotes, insert placeholders, and render the blockquotes individually final String placeholder = "!!BLOCKQUOTE!!"; List<String> list = new ArrayList<>(); StringBuilder sb = new StringBuilder(); StringBuilder bq = new StringBuilder(); for (String line : markdown.split("\n")) { if (line.length() > 0) { if (line.startsWith("> ")) { // accumulate blockquotes boolean newBQ = bq.length() == 0; bq.append(line.substring(2)).append('\n'); if (newBQ) { // insert a placeholder sb.append(placeholder).append(list.size()).append('\n'); } continue; } else if (bq.length() > 0) { // render blockquote by itself and reinject blockquote syntax String quote = bq.toString(); String rendered = renderMarkdown(quote, repository); bq.setLength(0); StringBuilder rsb = new StringBuilder(); for (String rl : rendered.split("\n")) { rsb.append("> ").append(rl).append('\n'); } list.add(rsb.toString()); } } sb.append(line).append('\n'); } String text = sb.toString(); try { IRuntimeManager runtimeManager = GitblitContext.getManager(IRuntimeManager.class); String canonicalUrl = runtimeManager.getSettings().getString(Keys.web.canonicalUrl, "https://localhost:8443"); // emphasize and link mentions String mentionReplacement = String.format(" **[@$1](%1s/user/$1)**", canonicalUrl); text = text.replaceAll("\\s@([A-Za-z0-9-_]+)", mentionReplacement); // link ticket refs String ticketReplacement = MessageFormat.format("$1[#$2]({0}/tickets?r={1}&h=$2)$3", canonicalUrl, repository); text = text.replaceAll("([\\s,]+)#(\\d+)([\\s,:\\.\\n])", ticketReplacement); // link commit shas int shaLen = settings.getInteger(Keys.web.shortCommitIdLength, 6); String commitPattern = MessageFormat.format("\\s([A-Fa-f0-9]'{'{0}'}')([A-Fa-f0-9]'{'{1}'}')", shaLen, 40 - shaLen); String commitReplacement = String.format(" [`$1`](%1$s/commit\\?r=%2$s&h=$1$2)", canonicalUrl, repository); text = text.replaceAll(commitPattern, commitReplacement); PegDownProcessor pd = new PegDownProcessor(ALL & ~SMARTYPANTS); RootNode astRoot = pd.parseMarkdown(text.toCharArray()); String slackMarkup = new SlackMarkupSerializer().toHtml(astRoot); slackMarkup = slackMarkup.replace("<pre><code>", "```\n"); slackMarkup = slackMarkup.replace("</code></pre>", "```\n"); // re-insert blockquotes for (int i = 0; i < list.size(); i++) { String quote = list.get(i); slackMarkup = slackMarkup.replace(placeholder + i, quote); } return slackMarkup; } catch (ParsingTimeoutException e) { log.error(null, e); return markdown; } } protected String getDisplayName(String username) { if (StringUtils.isEmpty(username)) { return username; } IUserManager userManager = GitblitContext.getManager(IUserManager.class); UserModel user = userManager.getUserModel(username); if (user != null) { String displayName = user.getDisplayName(); if (!StringUtils.isEmpty(displayName) && !username.equals(displayName)) { return displayName; } } return username; } /** * Determine if a ticket should be posted to a Slack channel. * * @param ticket * @return true if the ticket should be posted to a Slack channel */ protected boolean shallPost(TicketModel ticket) { IRuntimeManager runtimeManager = GitblitContext.getManager(IRuntimeManager.class); boolean shallPostTicket = runtimeManager.getSettings().getBoolean(Plugin.SETTING_POST_TICKETS, true); if (!shallPostTicket) { return false; } IRepositoryManager repositoryManager = GitblitContext.getManager(IRepositoryManager.class); RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); boolean shallPostRepo = slacker.shallPost(repository); return shallPostRepo; } protected String getUrl(TicketModel ticket) { return GitblitContext.getManager(IGitblit.class).getTicketService().getTicketUrl(ticket); } /** * Returns a link appropriate for the push. * * If both new and old ids are null, the summary page link is returned. * * @param repo * @param oldId * @param newId * @return a link */ protected String getUrl(String repo, String oldId, String newId) { IRuntimeManager runtimeManager = GitblitContext.getManager(IRuntimeManager.class); String canonicalUrl = runtimeManager.getSettings().getString(Keys.web.canonicalUrl, "https://localhost:8443"); if (oldId == null && newId != null) { // create final String hrefPattern = "{0}/commit?r={1}&h={2}"; return MessageFormat.format(hrefPattern, canonicalUrl, repo, newId); } else if (oldId != null && newId == null) { // log final String hrefPattern = "{0}/log?r={1}&h={2}"; return MessageFormat.format(hrefPattern, canonicalUrl, repo, oldId); } else if (oldId != null && newId != null) { // update/compare final String hrefPattern = "{0}/compare?r={1}&h={2}..{3}"; return MessageFormat.format(hrefPattern, canonicalUrl, repo, oldId, newId); } else if (oldId == null && newId == null) { // summary page final String hrefPattern = "{0}/summary?r={1}"; return MessageFormat.format(hrefPattern, canonicalUrl, repo); } return null; } private List<RevCommit> getCommits(String repositoryName, String baseId, String tipId) { IRepositoryManager repositoryManager = GitblitContext.getManager(IRepositoryManager.class); List<RevCommit> list = new ArrayList<RevCommit>(); try (Repository db = repositoryManager.getRepository(repositoryName)) { try (RevWalk walk = new RevWalk(db)) { walk.reset(); walk.sort(RevSort.TOPO); walk.sort(RevSort.REVERSE, true); RevCommit tip = walk.parseCommit(db.resolve(tipId)); RevCommit base = walk.parseCommit(db.resolve(baseId)); walk.markStart(tip); walk.markUninteresting(base); for (;;) { RevCommit c = walk.next(); if (c == null) { break; } list.add(c); } } catch (IOException e) { // Should never happen, the core receive process would have // identified the missing object earlier before we got control. log.error("failed to get commits", e); } } return list; } }